iT邦幫忙

2026 iThome 鐵人賽

DAY 25
0
Software Development

Android 鐵人賽: 天命最高 - 陪伴大家一步步打造屬於自己的app系列 第 25

RecyclerView × Room:Android 清單顯示與本機資料庫 CRUD

  • 分享至 

  • xImage
  •  

Day 25|RecyclerView × Room:Android 清單顯示與本機資料庫 CRUD

前幾天我們已經接觸過 Android 的 ListView、Adapter、Intent 與各種 UI 元件。

今天要再往真正 App 開發常見的架構前進一步:

RecyclerView 負責「把資料漂亮地顯示出來」,Room 負責「把資料真正保存下來」。

今天的練習其實可以分成兩條主線:

RecyclerView
資料 → Adapter → ViewHolder → Card Layout → RecyclerView

Room Database
UI → MainActivity → DAO → Room → SQLite
                    ↑
                  Entity

最後再把兩個觀念串起來,就已經很接近一般 Android App 常見的「資料 → 儲存 → 顯示」流程了。

參考資料:Android Developers — RecyclerView
https://developer.android.com/develop/ui/views/layout/recyclerview


一、為什麼已經有 ListView,還要學 RecyclerView?

前面的練習我們使用過 ListView

ListView 當然可以顯示清單,但當資料變多、畫面開始複雜,例如:

  • 圖片
  • 標題
  • 副標題
  • Button
  • Card
  • 不同排列方式
  • 點擊事件
  • 動態更新資料

這時候 RecyclerView 就更適合。

它最大的特色其實就在名字:

Recycler = 回收再利用 View。

畫面上只需要建立目前看得到的那些 Item,當 Item 滑出螢幕後,其 View 可以重新拿來顯示後面的資料。

因此核心概念可以想成:

資料 1 ─┐
資料 2 ─┤
資料 3 ─┤
資料 4 ─┤──→ Adapter ─→ ViewHolder ─→ Card
資料 5 ─┤
...    ─┘

而不是每一筆資料都重新建立一個完整 View。

參考資料:Android Developers — Create dynamic lists with RecyclerView
https://developer.android.com/develop/ui/views/layout/recyclerview


二、今天 RecyclerView 的資料結構

今天的 RecyclerView 範例準備了一組名稱資料,接著動態尋找:

flower1
flower2
flower3
...

這些 Drawable 圖片資源,再組成 MutableMap

我的程式資料結構是:

val nameArray = resources.getStringArray(R.array.name)
val dataList = mutableListOf<MutableMap<String, Any>>()

接著逐筆建立:

for (i in 0 until nameArray.size) {
    val item = mutableMapOf<String, Any>()

    item["name"] = nameArray[i]

    val resName = "flower${i + 1}"
    val resId = resources.getIdentifier(
        resName,
        "drawable",
        packageName
    )

    item["pic"] =
        if (resId != 0)
            resId
        else
            R.drawable.flower1

    dataList.add(item)
}

因此每一筆資料概念上就是:

{
    "name" : "名稱",
    "pic"  : R.drawable.flower1
}

這裡我覺得很值得記下來的是:

resources.getIdentifier()

它可以利用字串動態取得 Android Resource ID。

例如:

val resName = "flower${i + 1}"

當:

i = 0 → flower1
i = 1 → flower2
i = 2 → flower3

因此就不需要自己寫:

R.drawable.flower1
R.drawable.flower2
R.drawable.flower3
R.drawable.flower4

這對大量規則命名的圖片相當方便。我的 MainActivity 最後再把這些資料交給 CardAdapter 顯示。

參考資料:Android Developers — Resources
https://developer.android.com/guide/topics/resources/providing-resources


三、RecyclerView 三大角色

第一次學 RecyclerView 時,最容易被一堆 Class 搞混。

其實先記住三個角色即可:

RecyclerView
     │
     ▼
LayoutManager
     │
     ▼
Adapter
     │
     ▼
ViewHolder
     │
     ▼
card_layout.xml

RecyclerView

負責顯示整個清單。

val recyclerView =
    findViewById<RecyclerView>(R.id.recyclerView)

LayoutManager

負責決定 Item 怎麼排列。

今天使用:

recyclerView.layoutManager =
    LinearLayoutManager(this)

因此資料會以一般垂直清單排列。

最後指定 Adapter:

recyclerView.adapter =
    CardAdapter(this, dataList)

完整概念:

dataList
   ↓
CardAdapter
   ↓
RecyclerView
   ↓
LinearLayoutManager
   ↓
畫面上的 Card

參考資料:Android Developers — RecyclerView
https://developer.android.com/develop/ui/views/layout/recyclerview


四、Adapter 是 RecyclerView 的資料橋樑

今天建立的 CardAdapter

class CardAdapter(
    private val myContext: Context,
    private var dataList: MutableList<MutableMap<String, Any>>
) : RecyclerView.Adapter<CardAdapter.MyViewHolder>()

Adapter 本身不只是「放資料」。

它其實是在回答 RecyclerView 三個問題:

1. Item 長什麼樣子?
2. 這個位置應該顯示什麼資料?
3. 總共有多少筆資料?

所以 Adapter 最重要的三個函式就是:

onCreateViewHolder()
onBindViewHolder()
getItemCount()

參考資料:Android Developers — RecyclerView.Adapter
https://developer.android.com/reference/androidx/recyclerview/widget/RecyclerView.Adapter


五、ViewHolder 到底在做什麼?

這是 RecyclerView 最重要的觀念之一。

我的程式:

class MyViewHolder(itemView: View) :
    RecyclerView.ViewHolder(itemView) {

    val imageViewPic: ImageView =
        itemView.findViewById(R.id.imageView)

    val textViewName: TextView =
        itemView.findViewById(R.id.textView)
}

以前如果每次顯示 Item 都重新:

findViewById()

資料量大時會造成不必要的 View 查找。

ViewHolder 就先把:

ImageView
TextView

的參照保存起來。

概念上:

Card View
┌─────────────────────┐
│                     │
│    ImageView        │ ← imageViewPic
│                     │
│    花朵名稱          │ ← textViewName
│                     │
└─────────────────────┘
          ↑
      ViewHolder

RecyclerView 滑動時就可以重複使用。

參考資料:Android Developers — RecyclerView.ViewHolder
https://developer.android.com/reference/androidx/recyclerview/widget/RecyclerView.ViewHolder


六、onCreateViewHolder:建立 Card

RecyclerView 需要新的 Item 時,就會呼叫:

override fun onCreateViewHolder(
    parent: ViewGroup,
    viewType: Int
): MyViewHolder {

    val itemView =
        LayoutInflater
            .from(myContext)
            .inflate(
                R.layout.card_layout,
                parent,
                false
            )

    return MyViewHolder(itemView)
}

這裡最重要的是:

R.layout.card_layout

也就是:

RecyclerView 裡面每一張 Card 的 UI 樣板。

流程:

card_layout.xml
      ↓
LayoutInflater
      ↓
View
      ↓
MyViewHolder

所以 RecyclerView 本身只是容器。

真正「每一列長什麼樣子」,是由 card_layout.xml 決定。

參考資料:Android Developers — LayoutInflater
https://developer.android.com/reference/android/view/LayoutInflater


七、onBindViewHolder:把資料放進 Card

有了 Card,下一步就是填資料。

override fun onBindViewHolder(
    holder: MyViewHolder,
    position: Int
) {

    val data = dataList[position]

    val name =
        data["name"].toString()

    val picId =
        data["pic"] as Int

    holder.textViewName.text = name

    holder.imageViewPic
        .setImageResource(picId)
}

這裡的:

position

就是目前要顯示第幾筆資料。

例如:

position = 0
      ↓
dataList[0]
      ↓
name + pic
      ↓
ViewHolder
      ↓
第一張 Card

下一筆:

position = 1
      ↓
dataList[1]
      ↓
第二張 Card

因此:

onCreateViewHolder() 負責建立 View,
onBindViewHolder() 負責把資料綁到 View。

這兩個一定要分清楚。

參考資料:Android Developers — RecyclerView.Adapter
https://developer.android.com/reference/androidx/recyclerview/widget/RecyclerView.Adapter


八、RecyclerView 還可以處理點擊事件

今天的 CardAdapter 又增加了點擊事件。

點整張 Card:

holder.itemView.setOnClickListener {

    Toast.makeText(
        myContext,
        "Select : $name",
        Toast.LENGTH_SHORT
    ).show()
}

效果就是:

使用者點 Card
       ↓
取得這張 Card 的 name
       ↓
Toast
       ↓
Select : xxx

甚至還可以只讓 Card 裡面的 TextView 有自己的點擊行為:

holder.textViewName.setOnClickListener {

    val mainTextView =
        (myContext as Activity)
            .findViewById<TextView>(
                R.id.textView_data
            )

    mainTextView.text =
        "Select : $name"
}

也就是:

Card
├── ImageView
└── TextView ──點擊──→ MainActivity TextView 更新

所以 RecyclerView 的每一個 Item 裡面,可以有自己獨立的互動。

參考資料:Android Developers — RecyclerView
https://developer.android.com/develop/ui/views/layout/recyclerview


九、但是資料只放 RecyclerView 還不夠

到這裡有一個問題。

現在的:

dataList

只存在記憶體。

App 關閉之後,資料就沒了。

所以真正的 App 還需要:

資料持久化
Persistence

Android 本身可以使用 SQLite。

但是如果直接操作 SQLite,通常需要自己處理不少 SQL 與資料轉換工作。

因此 Android Jetpack 提供:

Room

Room 是建立在 SQLite 上方的資料庫抽象層。

可以把架構想成:

Android App
     ↓
    Room
     ↓
   SQLite

參考資料:Android Developers — Room
https://developer.android.com/training/data-storage/room


十、Room 三大核心:Entity、DAO、Database

今天第二個實作就是 Room。

Room 最重要的三個東西:

Entity
  ↓
 DAO
  ↓
Database

更完整:

MainActivity
     │
     ▼
    DAO
     │
     ▼
Room Database
     │
     ▼
SQLite
     ▲
     │
   Entity

我的 MyDatabase.kt 就把這三個核心結構建立起來。

參考資料:Android Developers — Room
https://developer.android.com/training/data-storage/room


十一、Entity:把 Kotlin Class 變成資料表

今天建立:

@Entity(tableName = "customer_table")
data class CustomerData(

    @PrimaryKey(autoGenerate = true)
    val uid: Int = 0,

    @NonNull
    @ColumnInfo(
        name = "customer_name",
        defaultValue = ""
    )
    val name: String,

    val email: String,

    val phone: String
)

這段非常重要。

它等於是在描述 SQLite:

customer_table
┌─────┬───────────────┬───────────────┬──────────────┐
│ uid │ customer_name │ email         │ phone        │
├─────┼───────────────┼───────────────┼──────────────┤
│  1  │ Alex          │ ...           │ ...          │
│  2  │ Amy           │ ...           │ ...          │
└─────┴───────────────┴───────────────┴──────────────┘

其中:

@PrimaryKey(autoGenerate = true)

表示:

uid 自動產生

所以新增資料:

CustomerData(
    name = name,
    email = email,
    phone = phone
)

不需要自己設定:

uid

Room 會自動處理。

參考資料:Android Developers — Defining data using Room entities
https://developer.android.com/training/data-storage/room/defining-data


十二、DAO:Android 與資料庫之間的操作介面

DAO 全名:

Data Access Object

簡單講:

專門負責操作資料庫。

今天的 DAO:

@Dao
interface DataDao {

    @Query("Select * from customer_table")
    suspend fun getAll(): List<CustomerData>

    @Query(
        "Select * from customer_table " +
        "where customer_name = :name"
    )
    suspend fun getCustomer(
        name: String
    ): List<CustomerData>

    @Insert(
        onConflict = OnConflictStrategy.IGNORE
    )
    suspend fun insertCustomer(
        customer: CustomerData
    ): Long

    @Update
    suspend fun update(
        customer: CustomerData
    ): Int

    @Delete
    suspend fun delete(
        customer: CustomerData
    ): Int
}

這裡剛好就是資料庫最基本的:

CRUD

C = Create → @Insert
R = Read   → @Query
U = Update → @Update
D = Delete → @Delete

參考資料:Android Developers — Accessing data using Room DAOs
https://developer.android.com/training/data-storage/room/accessing-data


十三、Query 還可以帶參數

例如今天查詢姓名:

@Query(
    "Select * from customer_table " +
    "where customer_name = :name"
)
suspend fun getCustomer(
    name: String
): List<CustomerData>

這裡:

:name

就是 Kotlin 傳進來的:

name

例如:

dao.getCustomer("Alex")

概念上就會執行:

SELECT *
FROM customer_table
WHERE customer_name = 'Alex'

因此 DAO 把 SQL 和 Kotlin 很自然地接起來了。

參考資料:Android Developers — Room DAO Query
https://developer.android.com/training/data-storage/room/accessing-data


十四、建立 Room Database

有 Entity、DAO 之後,還需要真正的 Database。

@Database(
    entities = [CustomerData::class],
    version = 1,
    exportSchema = false
)
abstract class MyDatabase :
    RoomDatabase() {

    abstract fun dataDao(): DataDao
}

這代表:

MyDatabase
    │
    ├── CustomerData Entity
    │
    └── DataDao

然後利用:

Room.databaseBuilder(
    context.applicationContext,
    MyDatabase::class.java,
    "customer_database"
).build()

真正建立:

customer_database

參考資料:Android Developers — Room Database
https://developer.android.com/reference/androidx/room/Database


十五、為什麼 Database 要做 Singleton?

我的程式還使用:

companion object {

    @Volatile
    private var INSTANCE:
        MyDatabase? = null

    fun getInstance(
        context: Context
    ): MyDatabase {

        return INSTANCE
            ?: synchronized(this) {

                val instance =
                    Room.databaseBuilder(
                        context.applicationContext,
                        MyDatabase::class.java,
                        "customer_database"
                    ).build()

                INSTANCE = instance

                instance
            }
    }
}

這個設計就是:

Singleton 單例模式

目的:

Activity A ─┐
Activity B ─┼──→ 同一個 MyDatabase
Activity C ─┘

而不是:

Activity A → Database 1
Activity B → Database 2
Activity C → Database 3

App 通常不需要一直建立新的 Room Database instance。

所以使用:

MyDatabase.getInstance(this)

取得共用 Database。

參考資料:Android Developers — Room
https://developer.android.com/training/data-storage/room


十六、Room 為什麼搭配 Coroutine?

今天 DAO 很多函式前面都有:

suspend

例如:

suspend fun getAll():
    List<CustomerData>
suspend fun insertCustomer(
    customer: CustomerData
): Long

因此 Activity 裡面使用:

lifecycleScope.launch {
    // Room 操作
}

這剛好也接回之前學過的 Kotlin Coroutine。

MainActivity
     ↓
lifecycleScope.launch
     ↓
Coroutine
     ↓
DAO
     ↓
Room
     ↓
SQLite

也就是之前學 Coroutine 時,可能還會覺得:

「Android 到底什麼時候會真的用到 suspend?」

今天 Room 就是一個非常實際的使用場景。

我的 CRUD 操作都是透過 lifecycleScope.launch 呼叫 DAO。

參考資料:Android Developers — Kotlin coroutines on Android
https://developer.android.com/kotlin/coroutines


十七、實作 Create:新增資料

首先取得使用者輸入:

val name =
    etName.text.toString()

val email =
    etEmail.text.toString()

val phone =
    etPhone.text.toString()

如果姓名沒有輸入:

if (name.isEmpty()) {

    Toast.makeText(
        this,
        "請輸入姓名",
        Toast.LENGTH_SHORT
    ).show()

    return@setOnClickListener
}

接著:

lifecycleScope.launch {

    val data =
        CustomerData(
            name = name,
            email = email,
            phone = phone
        )

    dao.insertCustomer(data)

    refreshList()

    clearInputs()

    Toast.makeText(
        this@MainActivity,
        "新增成功",
        Toast.LENGTH_SHORT
    ).show()
}

流程:

EditText
   ↓
CustomerData
   ↓
dao.insertCustomer()
   ↓
Room
   ↓
SQLite
   ↓
refreshList()
   ↓
更新畫面

參考資料:Android Developers — Room Insert
https://developer.android.com/training/data-storage/room/accessing-data


十八、實作 Read:查詢全部

查詢全部:

@Query(
    "Select * from customer_table"
)
suspend fun getAll():
    List<CustomerData>

Activity:

private fun refreshList() {

    lifecycleScope.launch {

        val allData =
            dao.getAll()

        val adapter =
            ArrayAdapter(
                this@MainActivity,
                android.R.layout
                    .simple_list_item_1,

                allData.map {
                    "${it.uid} | " +
                    "${it.name} | " +
                    "${it.phone}"
                }
            )

        lvResult.adapter =
            adapter

        tvResult.text =
            "資料筆數: ${allData.size}"
    }
}

這裡 Room 查出的:

List<CustomerData>

再透過:

map

轉成 ListView 可以顯示的文字。

例如:

1 | Alex | 0912345678
2 | Amy  | 0922333444
3 | Tom  | 0933555666

參考資料:Android Developers — Room Query
https://developer.android.com/training/data-storage/room/accessing-data


十九、實作 Read:依姓名查詢

按下單筆查詢:

val list =
    dao.getCustomer(name)

如果找到:

if (list.isNotEmpty()) {

    val data = list[0]

    etEmail.setText(data.email)

    etPhone.setText(data.phone)
}

因此查詢後,資料不只是顯示出來。

還可以重新放回:

EditText

方便後續修改。

完整流程:

輸入姓名
   ↓
getCustomer(name)
   ↓
Room Query
   ↓
找到 CustomerData
   ↓
email → EditText
phone → EditText

參考資料:Android Developers — Room DAO
https://developer.android.com/training/data-storage/room/accessing-data


二十、實作 Update:修改資料

更新時先找資料:

val list =
    dao.getCustomer(name)

取得原本的:

val target = list[0]

接著今天使用 Kotlin Data Class 很方便的:

copy()
val updatedData =
    target.copy(
        email =
            etEmail.text.toString(),

        phone =
            etPhone.text.toString()
    )

這樣:

uid   → 保留
name  → 保留
email → 修改
phone → 修改

最後:

dao.update(updatedData)

這就是 Kotlin data class 與 Room 搭配非常方便的地方。

參考資料:Kotlin — Data classes
https://kotlinlang.org/docs/data-classes.html


二十一、實作 Delete:刪除資料

刪除的流程也很直覺:

val list =
    dao.getCustomer(name)

找到資料:

if (list.isNotEmpty()) {

    dao.delete(list[0])

    refreshList()
}

所以不是直接告訴 Room:

刪除 Alex

而是:

Alex
 ↓
Query
 ↓
CustomerData
 ↓
@Delete
 ↓
Room
 ↓
SQLite

參考資料:Android Developers — Room Delete
https://developer.android.com/training/data-storage/room/accessing-data


二十二、今天其實把前面的 Kotlin 知識全部串起來了

寫到 Room 之後,我開始發現:

前面那些 Kotlin 基礎並不是分開存在的。

今天幾乎全部又出現一次。

例如:

data class CustomerData(...)

用到了:

Class
Data Class

DAO:

interface DataDao

又用到:

Interface

資料:

List<CustomerData>
MutableList<MutableMap<String, Any>>

又回到:

Collection
List
MutableList
Map

資料轉換:

allData.map {
    "${it.uid} | ${it.name} | ${it.phone}"
}

又回到:

Lambda

資料庫:

suspend fun getAll()

又回到:

Coroutine
suspend

Activity:

lifecycleScope.launch {
}

又把 Coroutine 真正放進 Android。

所以整條學習線開始串起來:

Kotlin 基本語法
       ↓
Collection
       ↓
Class / Interface
       ↓
Lambda
       ↓
Coroutine
       ↓
Android Activity
       ↓
Adapter
       ↓
RecyclerView
       ↓
Room
       ↓
真正具有資料功能的 Android App

參考資料:Kotlin Documentation
https://kotlinlang.org/docs/home.html


二十三、RecyclerView 與 Room 怎麼合體?

今天的兩個實驗目前可以分開看:

實驗 A
Array / MutableList
      ↓
RecyclerView
      ↓
CardAdapter
      ↓
畫面

以及:

實驗 B
EditText
   ↓
Room
   ↓
SQLite
   ↓
ListView

但真正 App 最終通常會變成:

             ┌──────────────┐
             │    SQLite    │
             └──────▲───────┘
                    │
             ┌──────┴───────┐
             │     Room     │
             └──────▲───────┘
                    │
             ┌──────┴───────┐
             │     DAO      │
             └──────▲───────┘
                    │
             List<CustomerData>
                    │
                    ▼
             ┌──────────────┐
             │   Adapter    │
             └──────┬───────┘
                    │
                    ▼
             ┌──────────────┐
             │ RecyclerView │
             └──────────────┘

也就是:

Room 管資料,RecyclerView 管畫面。

這就是今天最值得記住的一句話。

參考資料:Android Developers — Room
https://developer.android.com/training/data-storage/room


二十四、今天完整架構整理

把今天所有東西放在一起:

┌─────────────────────────────┐
│         MainActivity        │
│                             │
│ EditText / Button / UI      │
└──────────────┬──────────────┘
               │
               │ lifecycleScope.launch
               ▼
┌─────────────────────────────┐
│           DataDao           │
│                             │
│ getAll()                    │
│ getCustomer()               │
│ insertCustomer()            │
│ update()                    │
│ delete()                    │
└──────────────┬──────────────┘
               │
               ▼
┌─────────────────────────────┐
│        MyDatabase           │
│           Room              │
└──────────────┬──────────────┘
               │
               ▼
┌─────────────────────────────┐
│          SQLite             │
│                             │
│      customer_table         │
└─────────────────────────────┘


資料顯示:

Data
 │
 ▼
CardAdapter
 │
 ▼
ViewHolder
 │
 ▼
card_layout.xml
 │
 ▼
RecyclerView

今天已經不只是做一個 UI Demo。

而是開始碰到真正 Android App 很核心的:

資料結構
+
資料庫
+
非同步
+
清單 UI
+
Adapter

參考資料:Android Developers
https://developer.android.com/


二十五、今日重點整理

今天最重要的觀念,我整理成:

技術 功能
RecyclerView 顯示大量清單資料
Adapter 資料與 UI 的橋樑
ViewHolder 保存並重複利用 Item View
LayoutManager 決定 RecyclerView 排列方式
Room Android 本機資料庫抽象層
Entity 定義資料表
DAO 定義資料庫操作
@Query 查詢資料
@Insert 新增資料
@Update 更新資料
@Delete 刪除資料
Coroutine 非同步執行 Room 操作
lifecycleScope 配合 Activity Lifecycle 執行 Coroutine
SQLite Room 底層實際資料庫

今天的程式也完成了 Room 的新增、查詢、修改、刪除,以及 RecyclerView 的 Card 顯示與點擊互動。

參考資料:Android Developers
https://developer.android.com/


結語

今天開始有一種「Android 基礎零件終於組起來」的感覺。

RecyclerView 解決:

資料要怎麼有效率地呈現在畫面?

Room 解決:

資料要怎麼真正存在手機裡?

Coroutine 解決:

資料庫操作要怎麼避免卡住 UI?

所以今天可以濃縮成一條非常重要的資料流:

SQLite
  ↕
Room
  ↕
DAO
  ↕
Coroutine
  ↕
Activity
  ↕
Adapter
  ↕
RecyclerView
  ↕
使用者

從 Kotlin 的 ClassInterfaceCollectionLambdaCoroutine,一路走到 Android 的 RecyclerViewRoom,前面學過的東西開始真正連成一套 App 開發流程。

下一步,就可以繼續思考:

如果直接把 Room 查詢出來的 CustomerData 放進 RecyclerView,而不是 ListView,會發生什麼事?

那就會開始從單純的元件練習,逐漸走向真正的 Android 資料型 App。

Day 25 完成!

參考資料:Android Developers — Save data in a local database using Room
https://developer.android.com/training/data-storage/room


上一篇
別再用 Map 硬塞資料:用 Kotlin Data Class 重構 GridView
下一篇
Android 雲端資料庫初體驗:Firebase Realtime Database 資料寫入與 App 串接
系列文
Android 鐵人賽: 天命最高 - 陪伴大家一步步打造屬於自己的app30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言